Skip to content

fix: accept comma-separated values on deployment target and scope flags - #692

Draft
NickJosevski wants to merge 4 commits into
mainfrom
nj/issue-556
Draft

NickJosevski wants to merge 4 commits into
mainfrom
nj/issue-556

Conversation

@NickJosevski

Copy link
Copy Markdown
Contributor

Fixes #556

Root cause

--deployment-target is declared as a pflag StringArray, which does not split on commas:

  • pkg/cmd/release/deploy/deploy.go:173flags.StringArrayVarP(&deployFlags.DeploymentTargets.Value, ...)

Its legacy aliases are registered as StringSlice, which does split on commas:

  • pkg/util/pflagaliases.go:39flags.StringSlice(alias, nil, ""), used by AddFlagAliasesStringSlice
  • pkg/cmd/release/deploy/deploy.go:78FlagAliasSpecificMachines = "specificMachines" // octo wants a comma separated list ... but CSV also works because pflag does it for free

So today --specificMachines "ABC,XYZ" and --target "ABC,XYZ" both work, but the primary
--deployment-target "ABC,XYZ" sends the literal string ABC,XYZ to the server, producing
Unable to locate deployment target(s) named 'ABC,XYZ'. The bug is an inconsistency between a
flag and its own aliases, not a missing feature.

What changed

  • New executionscommon.ExpandCommaSeparated — splits each entry on commas, trims surrounding
    whitespace, drops blanks, preserves order and duplicates. Nil in, nil out.
  • Applied at the top of deployRun and runbookRun to: --environment, --tenant,
    --tenant-tag, --deployment-target / --run-target, --exclude-deployment-target /
    --exclude-run-target.
  • Help text for those flags now reads "(can be specified multiple times, or as a comma-separated list)".

Deliberately not applied to --variable (values are arbitrary text), --skip (step names),
--package / --git-resource (structured specs), --deployment-freeze-name, or --runbook-tag.

The repeat-the-flag form is unchanged, so existing scripts keep working. Expansion happens before
options is built, so the interactive backfill into resolvedFlags and
flag.GenerateAutomationCmd see the already-split values; GenerateAutomationCmd emits []string
as one --flag 'value' per element (pkg/util/flag/flag.go:77-84), so the echoed automation
command stays correct and re-runnable — --deployment-target 'ABC,XYZ' in becomes
--deployment-target 'ABC' --deployment-target 'XYZ' out.

Test evidence

go build ./... — clean.
go test ./pkg/... — 64 packages ok, 0 failures.

New tests:

  • TestExpandCommaSeparated (pkg/executionscommon/executionscommon_test.go) — comma form,
    repeated form, mixed form, values containing spaces, whitespace trimming around the comma,
    tenant-tag canonical values, blank entries, nil.
  • release deploy accepts comma-separated targets and environments; untenanted — asserts the
    wire request carries SpecificMachineNames: ["first Machine", "second Machine", "third Machine"]
    from --deployment-target "first Machine, second Machine" --deployment-target "third Machine".
  • release deploy accepts comma-separated tenants and tenant tags; tenanted.
  • runbook run accepts comma-separated environments and targets.

Open questions / options

1. Scope — this one flag, or the whole multi-value execution flag set?

  • Just --deployment-target: smallest blast radius, but leaves --environment "dev,test"
    still broken while --env "dev,test" works, which is the same bug wearing a different hat.
  • All multi-value flags including --variable and --skip: consistent, but actively harmful —
    --variable "Note:a,b" would silently become two malformed variables.
  • What this PR does — the five "who/where" selection flags on both execution commands.
    Recommendation: keep this. Four of the five (--environment, --tenant-tag, and both
    target flags) already accept CSV through their own legacy aliases, so this removes an
    inconsistency rather than inventing new parsing. --tenant has no alias and is the one genuinely
    new behaviour — included because splitting environments but not tenants would be arbitrary.
    Happy to drop --tenant if reviewers prefer strict "alias precedent only".
    --runbook-tag was left out (no legacy alias, selects runbooks rather than deployment scope) —
    flagging it since it is shaped exactly like --tenant-tag and could reasonably be included.

2. Values that legitimately contain a comma.

A target/environment/tenant named Web, Prod, or a tenant tag whose tag name contains a comma,
can no longer be passed to these flags at all — there is no escape hatch. Options:

  • Accept it (this PR). Commas in these names are rare, and for --environment/--tenant-tag/
    the target flags the aliases already behaved this way, so the regression surface is --tenant
    plus users who were passing commas through the primary flag names.
  • Support a backslash escape (--deployment-target 'Web\, Prod'), mirroring the escaping
    release create --package already does for colons. Costs a documented syntax users must learn.
  • Switch the flags to pflag StringSlice instead of a helper. Gets CSV-quoting for free
    (--deployment-target '"Web, Prod",Other') via encoding/csv, but the quoting is obscure,
    it changes --help type display from stringArray to strings, and it silently reinterprets
    any existing value containing a quote character.

Recommendation: ship as-is, and add escaping later only if a real customer hits a comma in a
name. Worth a reviewer's call since it is technically a breaking change for such names.

🤖 Generated with Claude Code

@sathvikkumar-octo

Copy link
Copy Markdown
Contributor

@NickJosevski picking this up

Comment thread pkg/cmd/release/deploy/deploy.go Outdated
func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error {
// these flags accept a comma-separated list as well as being specified multiple times
flags.Environments.Value = executionscommon.ExpandCommaSeparated(flags.Environments.Value)
flags.Tenants.Value = executionscommon.ExpandCommaSeparated(flags.Tenants.Value)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blank-drop can silently flip a tenanted deploy to untenanted. ExpandCommaSeparated drops blank entries and returns nil when everything was blank, and pkg/executor/release.go:178 routes on isTenanted := len(params.Tenants) > 0 || len(params.TenantTags) > 0.

Concrete scenario (CI is exactly where this happens): --tenant "$TENANT_A,$TENANT_B" with both variables unset/empty yields "," → expands to nil → the CLI silently submits an untenanted deployment to the environment. Before this PR the literal "," (or "") was sent as a tenant name and the server rejected it. Same class of change for --exclude-deployment-target "$X" with $X empty: the exclusion list silently becomes empty instead of erroring.

Suggest erroring (or at least warning) when a flag value expands to nothing but the flag was explicitly provided, e.g. check cmd.Flags().Changed(name) && len(expanded) == 0.

@NickJosevski NickJosevski Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in c64ab93. The routing claim still holds on this branch — pkg/executor/release.go:178 is isTenanted := len(params.Tenants) > 0 || len(params.TenantTags) > 0 — so an all-blank --tenant would have submitted an untenanted deployment.

I went with an error out of the expansion rather than the cmd.Flags().Changed(name) && len(expanded) == 0 check. ExpandCommaSeparated now fails on any component that is blank after trimming, and ExpandCommaSeparatedFlags propagates it as the first statement of deployRun/runbookRun:

--tenant has a blank value; check for an empty variable or a stray comma in ","

Reasons for that shape over the Changed check: it also catches the partial case ("$A,$B" with only $B empty), which len(expanded) == 0 misses and which narrows the deployment scope in exactly the same silent way; and it doesn't need each command to know which flags were explicitly provided. Error rather than warning because a blank component always means a caller-side substitution produced nothing — there is no invocation where "ignore it" is the right answer.

Covered by TestExpandCommaSeparated_RejectsBlankValues (empty string, lone comma, whitespace-only, blank in the middle, trailing comma, blank alongside a good repeat) and end-to-end by "release deploy rejects a blank comma-separated value rather than silently dropping it" in deploy_test.go.

One thing worth recording: the legacy aliases are covered too, but by accident of layering rather than by design. I drove util.ApplyFlagAliases with a throwaway test — --deployTo "," arrives on the primary flag as []string{"", ""}, and the expansion then rejects it. That only works because the expansion runs after alias application, which is the main reason I did not move the split to parse time; see my reply on the altitude thread.

}
result := make([]string, 0, len(values))
for _, value := range values {
for _, component := range strings.Split(value, ",") {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No escape hatch for names containing commas — and interactive mode now emits automation commands that re-split them. The split is unconditional, so a tenant/target/environment named e.g. Foo, Inc can no longer be passed through the primary flags at all (the PR description flags this; adding a data point on the second-order effect).

The sharper edge is the interactive echo: values chosen from a picker are backfilled into resolvedFlags and GenerateAutomationCmd emits --tenant 'Foo, Inc' (pkg/util/flag/flag.go []string case emits values verbatim, one flag per element). Pasting that "Automation Command" into CI now splits it into Foo and Inc — a hard error if those don't exist, or a deploy to the wrong tenants/targets if they do. So the interactive flow can now hand the user a re-runnable command that isn't re-runnable, with no workaround. Worth deciding on the escape-hatch question before shipping rather than after.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in b77c149, with one residual that I think you should look at before shipping.

\, now means a literal comma. splitOnUnescapedCommas walks the value and only treats a backslash specially when it immediately precedes a comma, so a backslash anywhere else is preserved verbatim and target names like DOMAIN\host are unaffected. On the interactive side, selections are escaped on the way into resolvedFlags with executionscommon.EscapeCommasdeploy.go:269-277, run.go:337-345 and 471-479, run_by_tag.go:324-326 — so the echoed Automation Command round-trips through the split instead of re-splitting a picked name. TestEscapeCommas_RoundTripsThroughExpand pins the round trip; "release deploy treats a backslash-escaped comma as part of the value" covers the flag path end to end (--deployment-target 'Web\, Prod,Other'["Web, Prod", "Other"]), plus unit cases in TestExpandCommaSeparated for the escaped/unescaped mix, DOMAIN\host, and a trailing backslash.

Residual: the escape hatch only works on the primary flags, not the legacy aliases. Those are registered with util.AddFlagAliasesStringSlice, so pflag CSV-parses them before we see them. I drove ApplyFlagAliases with a throwaway test: --deployTo 'Web\, Prod' lands on the primary flag as []string{"Web\\", "\" Prod\""} — mangled, with a stray backslash and csv quote characters, not even cleanly split. Same for --env, --tag, --tenantTag, --target, --specificMachines, --exclude-target, --excludeMachines. This is pre-existing — those aliases have always CSV-split and have never been able to carry a comma — so it is not a regression from this PR, but --target is a commonly used alias and we now document an escape that silently doesn't work there. Fixing it properly means registering those aliases as StringArray and routing them through the same expansion, which in turn needs ApplyFlagAliases's Value.String() round trip fixed (it reconstructs slice values by naively splitting the bracketed string, so any value containing a comma is lossy regardless). I left that out as bigger than #556.

On the design question you raised: I think \, is the right escape rather than a quoting form, because quoting can't survive the shell — by the time we see the value the user's quotes are gone, so an inner quote convention would need its own escape anyway. The flag help for all five flags now says "or as a comma-separated list; escape a comma inside a value as '\,'".

Cross-reference: the comment on #703 (pkg/executionscommon/executionscommon.go:309) is the same finding. b77c149 is already an ancestor of nj/tier1-integration-tests, so that branch has the escape hatch — same answer there, including the alias caveat above. I have not touched #703's branch.

Open question for you: do you want the alias flags brought onto the same escaping path in this PR, or is "escape hatch on the primary flags only, legacy aliases stay CSV" the shipping position?

}

func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error {
// these flags accept a comma-separated list as well as being specified multiple times

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Altitude: the expansion lives in two run functions rather than the flag layer. Two costs:

  1. Same-named flags now behave differently across commands: tenant connect --environment/-e (pkg/cmd/tenant/connect/connect.go:108) still does not split commas, so -e "dev,test" works on release deploy but sends the literal string on tenant connect.
  2. Mutating flags.X.Value at the top of the run function creates an ordering dependency — any future code reading these flags in PreRunE or before these lines sees unsplit values, and every new command must remember to add the block.

A parse-time mechanism (a small splitting pflag.Value wrapper, or a util.StringArrayCommaSeparated(...) registration helper next to AddFlagAliasesStringSlice in pkg/util/pflagaliases.go) would give every command the behavior consistently and remove the ordering hazard.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both sub-points hold, but I've left the expansion where it is and want your call, because the obvious parse-time version silently undoes the blank-value fix from c64ab93.

Point 1 confirmed. tenant connect registers -e/--environment with StringArrayVarP (connect.go:108) and does no splitting, so -e "dev,test" is one environment name there and two on release deploy.

Point 2 is real but currently latent. The only PreRunE on either command is util.ApplyFlagAliases, and the expansion is the first statement of deployRun/runbookRun, so nothing reads unsplit values today. It is an invariant held by convention, which is your point.

Why I didn't move it. A splitting pflag.Value has to reject blanks in Set, and util.ApplyFlagAliases discards Set errors — pflagaliases.go:63 and :66 are both _ = primaryFlag.Value.Set(...). I checked both shapes with a throwaway test driving ApplyFlagAliases:

  • with a Value that errors on a blank component, --deployTo "," leaves the primary flag nil and no error escapes;
  • with the expansion where it is now, the same input arrives as []string{"", ""} and fails with --environment has a blank value; ....

So parse-time splitting reintroduces exactly the silent scope change from the blank-drop thread, on the legacy alias path (--deployTo, --env, --tag, --tenantTag, --target, --specificMachines, --exclude-target, --excludeMachines).

The version that doesn't regress is three parts:

  1. util.ApplyFlagAliases returns error. 7 call sites (buildinformation upload, package upload, release create, release deploy, release progression allow, release progression prevent, runbook run) — each is already inside a PreRunE that returns error, so it is one line each. Checked the other alias Set paths: string/stringArray Set never fail and bool aliases are fed from a bool flag's own String(), so the new splitting Value would be the only thing that can error.
  2. A registration helper next to AddFlagAliasesStringSlice wrapping a splitting/blank-rejecting pflag.Value (keeping Type() == "stringArray" so help output and completion don't change; nothing in the repo calls GetStringArray, so that's free).
  3. The split/escape logic moves out of executionscommon into pkg/util or a leaf package, because pkg/util can't import executionscommon.

I think (1) is worth doing on its own merits — swallowing Set errors during alias application is a latent bug independent of this PR. What I don't want to do unilaterally is (2)+(3) plus opting the other commands into comma splitting, under a PR scoped to the deploy/runbook scope flags — there are 11 StringArray --environment flags in pkg/cmd (these two, tenant connect, and the eight account ... create commands), and changing the rest is an observable behaviour change to commands #556 doesn't mention.

Decision I need: land #556 with the expansion in the two run functions and take (1)+(2)+(3) as a follow-up that also decides which other commands opt in — or do you want all of it here, and if so should the splitting Value go on every StringArray --environment or only on the deploy/runbook ones?

Comment thread pkg/cmd/runbook/run/run.go Outdated

func runbookRun(cmd *cobra.Command, f factory.Factory, flags *RunFlags) error {
// these flags accept a comma-separated list as well as being specified multiple times
flags.Environments.Value = executionscommon.ExpandCommaSeparated(flags.Environments.Value)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this five-line block is duplicated verbatim from deployRun. If the flag set grows (the PR description already floats --runbook-tag), the two lists have to be kept in sync by hand. A tiny shared helper would collapse both call sites, e.g. executionscommon.ExpandCommaSeparatedAll(&flags.Environments.Value, &flags.Tenants.Value, ...) taking ...*[]string.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in 8c07b92. executionscommon.ExpandCommaSeparatedFlags(...*flag.Flag[[]string]) expands in place, so both call sites are now one call: deploy.go:202 and run.go:205.

I took *flag.Flag[[]string] rather than the ...*[]string you suggested because the flags carry their own Name, and that turned out to be load-bearing once blanks became an error (c64ab93, on the other thread) — the helper can say --tenant has a blank value instead of a message with no idea which flag it came from. A ...*[]string signature would have needed the name passed alongside each pointer, which is the two-lists-to-keep-in-sync problem again.

Covered by TestExpandCommaSeparatedFlags (mutation in place across several flags, and the error naming the offending flag).

NickJosevski and others added 4 commits September 15, 2026 17:22
`--deployment-target "ABC,XYZ"` was sent to the server as a single target
name because the flag is a pflag StringArray, while its legacy aliases
(`--target`, `--specificMachines`) are StringSlice and already split on
commas. Expand comma-separated values for the environment, tenant,
tenant-tag and target flags on `release deploy` and `runbook run`, so the
comma form matches the repeat-the-flag form. Values that can legitimately
contain a comma (--variable, --skip, package/git-resource specs) are left
alone.

Fixes #556

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Review feedback: the five-line expansion block at the top of deployRun was
duplicated verbatim in runbookRun, so any new multi-value flag has to be added
to two hand-maintained lists.

ExpandCommaSeparatedFlags takes the flags themselves and expands them in place,
leaving one call per command.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… them

Review feedback: dropping blanks let an explicitly-provided flag expand to
nothing. Because pkg/executor/release.go routes on
`len(params.Tenants) > 0 || len(params.TenantTags) > 0`, `--tenant "$A,$B"`
with both variables unset expanded to nil and the CLI silently submitted an
*untenanted* deployment to the environment. Before this branch the literal ","
was sent as a tenant name and the server rejected it. The same class of change
applied to `--exclude-deployment-target "$X"` with $X empty, where the
exclusion list quietly became empty.

A blank component always means a caller-side substitution produced nothing, so
ExpandCommaSeparated now returns an error naming the flag and quoting the
offending value. This also covers the partial case ("$A,$B" with only $B
empty), which would otherwise have silently narrowed the deployment scope.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Review feedback: the split was unconditional, so a tenant/target/environment
named e.g. "Foo, Inc" could no longer be passed through the primary flags at
all. The sharper edge was the interactive echo — a value chosen from a picker
is backfilled into resolvedFlags and flag.GenerateAutomationCmd emits it
verbatim, so the printed "Automation Command" was not re-runnable: pasting it
into CI would split "Foo, Inc" back into two names, erroring if they don't
exist or deploying to the wrong tenants if they do.

`\,` now means a literal comma. A backslash anywhere else is preserved
verbatim, so names such as DOMAIN\host are unaffected. Interactive selections
are escaped with executionscommon.EscapeCommas on the way into the automation
command, so the echoed command round-trips.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support comma-delimited values on octopus release deploy --deployment-target command

2 participants